import functools
import itertools
import operator
from uuid import uuid4

from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils import timezone
from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _
from jsonschema import ValidationError

from sysreptor.pentests import querysets
from sysreptor.pentests.collab.text_transformations import SelectionRange
from sysreptor.pentests.fielddefinition.predefined_fields import (
    FINDING_FIELDS_CORE,
    FINDING_FIELDS_PREDEFINED,
    REPORT_FIELDS_CORE,
    REPORT_FIELDS_PREDEFINED,
    finding_fields_default,
    finding_ordering_default,
    report_sections_default,
)
from sysreptor.pentests.fielddefinition.validators import (
    DefaultNotesValidator,
    FindingGroupingValidator,
    FindingOrderingValidator,
    SectionDefinitionValidator,
)
from sysreptor.pentests.models.common import (
    ImportableMixin,
    Language,
    LanguageMixin,
    LockableMixin,
    ReviewStatus,
)
from sysreptor.pentests.rendering.error_messages import ErrorMessage
from sysreptor.users.models import PentestUser
from sysreptor.utils.crypto.fields import EncryptedField
from sysreptor.utils.decorators import cache
from sysreptor.utils.fielddefinition.mixins import EncryptedCustomFieldsMixin
from sysreptor.utils.fielddefinition.types import (
    FieldDataType,
    FieldDefinition,
    parse_field_definition,
    serialize_field_definition,
)
from sysreptor.utils.fielddefinition.utils import (
    HandleUndefinedFieldsOptions,
    ensure_defined_structure,
    iterate_fields,
    set_field_origin,
    set_value_at_path,
)
from sysreptor.utils.fielddefinition.validators import FieldDefinitionValidator
from sysreptor.utils.history import HistoricalRecords
from sysreptor.utils.models import BaseModel


class ProjectTypeScope(models.TextChoices):
    GLOBAL = "global", _("Global")
    PRIVATE = "private", _("Private")
    PROJECT = "project", _("Project")


class ProjectType(LockableMixin, LanguageMixin, ImportableMixin, BaseModel):
    name = models.CharField(max_length=255, null=False, blank=False, db_index=True)
    status = models.CharField(max_length=255, default=ReviewStatus.IN_PROGRESS, db_index=True)
    tags = ArrayField(base_field=models.CharField(max_length=255), default=list, blank=True, db_index=True)
    usage_count = models.PositiveIntegerField(default=0, db_index=True)

    # PDF Template
    report_template = EncryptedField(base_field=models.TextField(default="", blank=True))
    report_styles = EncryptedField(base_field=models.TextField(default="", blank=True))
    report_preview_data = EncryptedField(base_field=models.JSONField(encoder=DjangoJSONEncoder, default=dict, blank=True))

    # Field definitions
    report_sections = models.JSONField(
        encoder=DjangoJSONEncoder,
        validators=[SectionDefinitionValidator(core_fields=REPORT_FIELDS_CORE, predefined_fields=REPORT_FIELDS_PREDEFINED)],
        default=report_sections_default,
        blank=True,
    )
    finding_fields = models.JSONField(
        encoder=DjangoJSONEncoder,
        validators=[FieldDefinitionValidator(core_fields=FINDING_FIELDS_CORE, predefined_fields=FINDING_FIELDS_PREDEFINED)],
        default=finding_fields_default,
        blank=True,
    )
    finding_ordering = models.JSONField(
        encoder=DjangoJSONEncoder,
        validators=[FindingOrderingValidator()],
        default=finding_ordering_default,
        blank=True,
    )
    finding_grouping = models.JSONField(
        encoder=DjangoJSONEncoder,
        validators=[FindingGroupingValidator()],
        default=None,
        null=True,
        blank=True,
    )

    # Notes
    default_notes = ArrayField(
        base_field=models.JSONField(encoder=DjangoJSONEncoder, null=False, blank=False),
        validators=[DefaultNotesValidator()],
        default=list,
        blank=True,
    )

    linked_project = models.ForeignKey(to="PentestProject", on_delete=models.CASCADE, null=True, blank=True)
    linked_user = models.ForeignKey(to=PentestUser, on_delete=models.CASCADE, null=True, blank=True)

    copy_of = models.ForeignKey(to="ProjectType", on_delete=models.SET_NULL, null=True, blank=True)

    history = HistoricalRecords(cascade_delete_history=True, excluded_fields=['usage_count'])
    objects = querysets.ProjectTypeManager()

    class Meta:
        constraints = [
            models.CheckConstraint(
                name="linked_project_or_user",
                condition=models.Q(linked_project=None) | models.Q(linked_user=None),
            ),
        ]

    @property
    def finding_fields_obj(self) -> FieldDefinition:
        return parse_field_definition(self.finding_fields)

    @property
    def all_report_fields_obj(self) -> FieldDefinition:
        all_fields = list(itertools.chain(*map(lambda s: s['fields'], self.report_sections)))
        return parse_field_definition(all_fields)

    @property
    def scope(self) -> ProjectTypeScope:
        if self.linked_project_id:
            return ProjectTypeScope.PROJECT
        elif self.linked_user_id:
            return ProjectTypeScope.PRIVATE
        elif not self.linked_project_id and not self.linked_user_id:
            return ProjectTypeScope.GLOBAL

    def __str__(self) -> str:
        return self.name

    def is_file_referenced(self, f) -> bool:
        # Always assume a file is referenced, because the reference might be dynamically generated via template language
        return True

    def get_unsupported_fields(self, field_def: list[dict]|None) -> list[str]:
        # Validate finding ordering contains only defined fields of supported types
        unsupported_fields = []
        for o in field_def or []:
            d = self.finding_fields_obj.get(o["field"])
            if not d or d.type in [FieldDataType.LIST, FieldDataType.OBJECT, FieldDataType.USER]:
                unsupported_fields.append(o["field"])
        return unsupported_fields

    def clean(self):
        if unsupported_fields := self.get_unsupported_fields(self.finding_ordering):
            raise ValidationError(f"Unsupported fields in finding ordering: {unsupported_fields}")
        if unsupported_fields := self.get_unsupported_fields(self.finding_grouping):
            raise ValidationError(f"Unsupported fields in finding groups: {unsupported_fields}")

    def save(self, *args, **kwargs):
        # Ensure static fields are marked correctly
        for section in self.report_sections:
            section['fields'] = serialize_field_definition(set_field_origin(
                definition=parse_field_definition(section['fields']),
                predefined_fields=REPORT_FIELDS_CORE | REPORT_FIELDS_PREDEFINED,
            ))
        self.finding_fields = serialize_field_definition(set_field_origin(
            definition=self.finding_fields_obj,
            predefined_fields=FINDING_FIELDS_CORE | FINDING_FIELDS_PREDEFINED),
        )

        # Remove unknown fields from finding_ordering
        for ordering_def in list(self.finding_ordering):
            d = self.finding_fields_obj.get(ordering_def["field"])
            if not d or d.type in [FieldDataType.LIST, FieldDataType.OBJECT, FieldDataType.USER]:
                self.finding_ordering.remove(ordering_def)

        # Remove unknown fields from finding_grouping
        for group_def in list(self.finding_grouping or []):
            d = self.finding_fields_obj.get(group_def["field"])
            if not d or d.type in [FieldDataType.LIST, FieldDataType.OBJECT, FieldDataType.USER]:
                self.finding_grouping.remove(group_def)

        # Ensure correct structure of report_preview_data
        if set(self.changed_fields).intersection({"report_preview_data", "report_sections", "finding_fields"}):
            def update_preview_data_defaults(new_data, old_data, new_definition, old_definition):
                new_values = {t[0]: t for t in iterate_fields(new_data, new_definition)}
                old_values = {t[0]: t for t in iterate_fields(old_data, old_definition)}
                for path, (_, new_value, new_definition) in new_values.items():
                    if path not in old_values:
                        continue
                    _, old_value, old_definition = old_values[path]
                    if (
                        new_definition.type in [FieldDataType.MARKDOWN, FieldDataType.STRING]
                        and old_definition.type == new_definition.type
                        and old_value == old_definition.default
                        and old_value == new_value
                    ):
                        set_value_at_path(new_data, path, new_definition.default)

            report_data = self.report_preview_data.get("report")
            if not isinstance(report_data, dict):
                report_data = {}

            # Update preview data fields containing old default values to new default values
            self.report_preview_data["report"] = ensure_defined_structure(
                value=report_data,
                definition=self.all_report_fields_obj,
                handle_undefined=HandleUndefinedFieldsOptions.FILL_DEMO_DATA,
            )
            if "report_sections" in self.changed_fields:
                update_preview_data_defaults(
                    new_data=self.report_preview_data["report"],
                    old_data=report_data,
                    new_definition=self.all_report_fields_obj,
                    old_definition=parse_field_definition(list(itertools.chain(*map(lambda s: s['fields'], self.initial["report_sections"])))),
                )

            findings_data = self.report_preview_data.get("findings")
            if not isinstance(findings_data, list) or not all(map(lambda f: isinstance(f, dict), findings_data)):
                # Generate findings with demo data
                # Static values for core fields
                findings_data = [
                    {"title": "First Demo Finding", "cvss": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L"},
                    {"title": "Second Demo Finding", "cvss": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:L"},
                ]
            self.report_preview_data["findings"] = [
                {
                    "id": f.get('id') or str(uuid4()),
                    "created": f.get('created') or timezone.now().isoformat(),
                    "order": fidx + 1,
                } | ensure_defined_structure(
                    value=f,
                    definition=self.finding_fields_obj,
                    handle_undefined=HandleUndefinedFieldsOptions.FILL_DEMO_DATA,
                )
                for fidx, f in enumerate(findings_data)
                if isinstance(f, dict)
            ]
            # Update preview data fields containing old default values to new default values
            if "finding_fields" in self.changed_fields:
                for new_finding in self.report_preview_data["findings"]:
                    old_finding = next(filter(lambda f: f.get("id") == new_finding.get("id"), findings_data), None)
                    if not old_finding:
                        continue
                    update_preview_data_defaults(
                        new_data=new_finding,
                        old_data=old_finding,
                        new_definition=self.finding_fields_obj,
                        old_definition=parse_field_definition(self.initial["finding_fields"]),
                    )

        return super().save(*args, **kwargs)

    def copy(self, **kwargs):
        return ProjectType.objects.copy(self, **kwargs)


class PentestProject(LanguageMixin, ImportableMixin, BaseModel):
    name = models.CharField(max_length=255, null=False, blank=False, db_index=True)
    tags = ArrayField(base_field=models.CharField(max_length=255), default=list, blank=True, db_index=True)
    project_type = models.ForeignKey(to="ProjectType", on_delete=models.RESTRICT)
    imported_members = ArrayField(base_field=models.JSONField(encoder=DjangoJSONEncoder), default=list, blank=True)

    unknown_custom_fields = EncryptedField(
        base_field=models.JSONField(encoder=DjangoJSONEncoder, default=dict, blank=True, null=True),
        null=True,
        blank=True,
    )
    override_finding_order = models.BooleanField(default=False)

    readonly = models.BooleanField(default=False, db_index=True)
    readonly_since = models.DateTimeField(null=True, db_index=True, editable=False)

    copy_of = models.ForeignKey(to="PentestProject", on_delete=models.SET_NULL, null=True)

    history = HistoricalRecords(cascade_delete_history=True)
    objects = querysets.PentestProjectManager()

    @property
    def field_definition(self) -> FieldDefinition:
        return self.project_type.all_report_fields_obj

    @property
    def data(self):
        return functools.reduce(operator.or_, map(lambda s: s.data, self.sections.all()), {})

    @property
    def data_all(self) -> dict:
        return (self.unknown_custom_fields or {}) | functools.reduce(
            operator.or_, map(lambda s: s.data_all, self.sections.all()), {},
        )

    def __str__(self) -> str:
        return self.name

    def copy(self, **kwargs):
        return PentestProject.objects.copy(self, **kwargs)

    def set_members(self, members: list['ProjectMemberInfo'], **kwargs):
        return PentestProject.objects.set_members(instance=self, members=members, **kwargs)

    def perform_checks(self) -> list[ErrorMessage]:
        from sysreptor.pentests.checks import run_checks

        return list(run_checks(self))

    def is_file_referenced(self, f, sections=True, findings=True, notes=True) -> bool:
        # Project data (sections)
        if sections:
            if f.name in str(self.data_all):
                return True

        # Findings
        if findings:
            for finding in self.findings.all():
                if f.name in str(finding.data_all):
                    return True

        # Notes
        if notes:
            for note in self.notes.all():
                if note.is_file_referenced(f):
                    return True
        return False


class ProjectMemberRole(BaseModel):
    role = models.CharField(max_length=50, unique=True)
    default = models.BooleanField(default=False)

    @classproperty
    @cache("ProjectMemberRole.predefined_roles", timeout=10)
    def predefined_roles(cls):
        return ProjectMemberRole.objects.all()

    @classproperty
    def default_roles(cls) -> list[str]:
        return [r.role for r in cls.predefined_roles if r.default]


class ProjectMemberInfo(BaseModel):
    project = models.ForeignKey(to=PentestProject, on_delete=models.CASCADE, related_name="members")
    user = models.ForeignKey(to=PentestUser, on_delete=models.PROTECT)

    roles = ArrayField(base_field=models.CharField(max_length=50, null=False, blank=False), default=list, blank=True)

    history = HistoricalRecords()

    class Meta:
        unique_together = [("project", "user")]


class ReportSection(EncryptedCustomFieldsMixin, BaseModel):
    project = models.ForeignKey(to=PentestProject, on_delete=models.CASCADE, null=False, related_name="sections")
    section_id = models.CharField(max_length=255, null=False, db_index=True, editable=False)

    assignee = models.ForeignKey(to=PentestUser, on_delete=models.SET_NULL, null=True, blank=True)
    status = models.CharField(max_length=255, default=ReviewStatus.IN_PROGRESS, db_index=True)

    history = HistoricalRecords()
    objects = querysets.ReportSectionManager()

    class Meta(BaseModel.Meta):
        unique_together = [("project", "section_id")]

    @property
    def project_type(self) -> ProjectType:
        return self.project.project_type

    @property
    def section_definition(self) -> dict:
        return next(filter(lambda s: s.get("id") == self.section_id, self.project_type.report_sections), {})

    @property
    def section_label(self) -> str:
        return self.section_definition.get("label") or ""

    @property
    def section_fields(self) -> list:
        return [f['id'] for f in self.section_definition.get("fields", [])]

    @property
    def field_definition(self) -> FieldDefinition:
        return parse_field_definition(self.section_definition.get("fields", []))

    @property
    def language(self) -> Language:
        return self.project.language

    @property
    def title(self) -> str:
        return self.section_label


class PentestFinding(EncryptedCustomFieldsMixin, BaseModel):
    finding_id = models.UUIDField(default=uuid4, db_index=True, editable=False)
    project = models.ForeignKey(to=PentestProject, on_delete=models.CASCADE, null=False, related_name="findings")

    template_id = EncryptedField(base_field=models.UUIDField(null=True, blank=True), null=True, blank=True)
    assignee = models.ForeignKey(to=PentestUser, on_delete=models.SET_NULL, null=True, blank=True)
    status = models.CharField(max_length=20, default=ReviewStatus.IN_PROGRESS, db_index=True)
    order = models.PositiveIntegerField(default=0, db_index=True)

    history = HistoricalRecords()
    objects = querysets.PentestFindingManager()

    class Meta(BaseModel.Meta):
        unique_together = [("project", "finding_id")]

    @property
    def field_definition(self) -> FieldDefinition:
        return self.project.project_type.finding_fields_obj

    @property
    def language(self) -> Language:
        return self.project.language

    @property
    def title(self) -> str:
        return self.data.get("title")

    def __str__(self) -> str:
        return self.title


class CommentStatus(models.TextChoices):
    OPEN = "open", _("Open")
    RESOLVED = "resolved", _("Resolved")


class Comment(BaseModel):
    finding = models.ForeignKey(
        to=PentestFinding, on_delete=models.CASCADE, related_name="comments", null=True, blank=True,
    )
    section = models.ForeignKey(
        to=ReportSection, on_delete=models.CASCADE, related_name="comments", null=True, blank=True,
    )

    user = models.ForeignKey(to=PentestUser, on_delete=models.SET_NULL, null=True, blank=True)
    status = models.CharField(max_length=20, choices=CommentStatus.choices, default=CommentStatus.OPEN)
    text = EncryptedField(base_field=models.TextField())

    path = models.TextField()
    text_range_from = models.PositiveIntegerField(null=True, blank=True)
    text_range_to = models.PositiveIntegerField(null=True, blank=True)
    text_original = EncryptedField(base_field=models.TextField(null=True, blank=True), null=True, blank=True)

    objects = querysets.CommentManager()

    class Meta(BaseModel.Meta):
        constraints = [
            models.CheckConstraint(
                name="comment_finding_or_section",
                condition=(models.Q(finding__isnull=True) & models.Q(section__isnull=False))
                | (models.Q(finding__isnull=False) & models.Q(section__isnull=True)),
            ),
        ]

    @property
    def path_absolute(self) -> str:
        return (
            f"sections.{self.section.section_id}." if self.section else f"findings.{self.finding.finding_id}."
        ) + self.path

    @property
    def text_range(self) -> SelectionRange|None:
        if self.text_range_from is not None and self.text_range_to is not None:
            return SelectionRange(anchor=self.text_range_from, head=self.text_range_to)
        return None

    @text_range.setter
    def text_range(self, value: SelectionRange | dict | None):
        if isinstance(value, dict):
            value = SelectionRange.from_dict(value)

        if value and not value.empty:
            self.text_range_from = value.from_
            self.text_range_to = value.to
        else:
            self.text_range_from = None
            self.text_range_to = None

    def to_dict_short(self):
        return {
            "id": self.id,
            "path": self.path_absolute,
            "text_range": {"from": self.text_range_from, "to": self.text_range_to} if self.text_range else None,
        }

    @property
    def project_id(self):
        if self.finding_id:
            return self.finding.project_id
        elif self.section_id:
            return self.section.project_id
        return None

    @property
    def project(self):
        if self.finding_id:
            return self.finding.project
        elif self.section_id:
            return self.section.project
        return None


class CommentAnswer(BaseModel):
    comment = models.ForeignKey(to=Comment, on_delete=models.CASCADE, related_name="answers")
    user = models.ForeignKey(to=PentestUser, on_delete=models.SET_NULL, null=True, blank=True)
    text = EncryptedField(base_field=models.TextField())

    class Meta(BaseModel.Meta):
        ordering = ["created"]
